// @vitest-environment jsdom // // `/blog/[slug]` — the three static-generation exports: `getStaticPaths` // (enumerate slugs), `loader` (fetch this post, `notFound()` for a miss), and // `meta(loaderData)` (per-post title). Plus the article render. The page reads // `useLoaderData` and renders a `` island — both come from // `@voltro/web`, mocked here so `useLoaderData` returns our post and `island` // is the identity wrapper. import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { act, createElement, type ComponentType, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { posts, type Post } from '../../../content/posts' import en from '../../../locales/en' const loaderData = vi.fn<() => Post>() const fill = (msg: string, values?: Record): string => values ? msg.replace(/\{(\w+)\}/g, (_, k: string) => String(values[k] ?? `{${k}}`)) : msg class NotFoundError extends Error {} vi.mock('@voltro/web', () => ({ useLoaderData: () => loaderData(), // Real `notFound()` throws to abort the loader; a distinct error lets us // assert the miss path without pulling in the router runtime. notFound: (detail?: string) => { throw new NotFoundError(detail) }, // Real `island()` wraps the component in a hydration marker; identity keeps // the inner component observable in a unit test. island: (Component: ComponentType) => Component, // `withLocalePrefix` (lib/locale) reads useLocation; the post page pins the // locale via useLocale, so any bare path is fine here. useLocation: () => '/', })) // The page renders chrome (back-link, reading-time) via /useLocale — resolve // them against the real English catalog with a controllable "current locale". vi.mock('@voltro/i18n', () => ({ defineCatalog: (c: T): T => c, defineLocale: () => (c: T): T => c, useLocale: () => 'en', T: ({ id, values }: { id: string; values?: Record }) => fill((en as Record)[id] ?? id, values), })) const { default: BlogPost, getStaticPaths, loader, meta } = await import('./page') ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true let container: HTMLDivElement let root: Root const render = (node: ReactNode): void => { container = document.createElement('div') document.body.appendChild(container) act(() => { root = createRoot(container) root.render(node) }) } beforeEach(() => { loaderData.mockReset() }) afterEach(() => { if (root) act(() => root.unmount()) container?.remove() document.body.innerHTML = '' }) describe('blog [slug] — getStaticPaths', () => { test('enumerates one path per post', async () => { const paths = await getStaticPaths() expect(paths.map((p) => p.params.slug)).toEqual(posts.map((p) => p.slug)) }) }) describe('blog [slug] — loader', () => { test('returns the matching post for a known slug', async () => { const post = await loader({ params: { slug: 'hello-static' } } as never) expect(post.slug).toBe('hello-static') expect(post.title).toContain('zero JavaScript') }) test('throws notFound for an unknown slug', async () => { await expect(loader({ params: { slug: 'nope' } } as never)).rejects.toBeInstanceOf( NotFoundError, ) }) }) describe('blog [slug] — meta', () => { test('builds a per-post title + description from the loader data', () => { const post = posts[0]! const m = meta({ loaderData: post }) expect(m.title).toContain(post.title) expect(m.description).toBe(post.excerpt) }) }) describe('blog [slug] — render', () => { test('renders the post title, meta line, and each body paragraph', () => { const post = posts[1]! loaderData.mockReturnValue(post) render(createElement(BlogPost)) expect(container.querySelector('article h1')?.textContent).toBe(post.title) expect(container.textContent).toContain(`${post.readingMinutes} min read`) const paras = post.body.split('\n\n') expect(container.querySelectorAll('article p').length).toBeGreaterThanOrEqual(paras.length) }) })